API Design Patterns — 14장 연관 리소스 딥다이브 학습 노트

출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/


14장에서 다루는 내용

  • 연관 리소스를 통해 다대다 관계를 다루는 방법 — DB의 join table을 API 리소스로
  • 연관 리소스 이름 명명법 — Membership, Association, Enrollment 등
  • 연관 리소스의 표준 메서드 — Create, Get, List, Update, Delete + 별칭 메서드
  • 고유성 제약과 읽기 전용 필드 — 중복 방지, 참조 불변성
  • 참조 무결성을 다루는 방법 — 캐스케이드, 제한, null 설정, 방치

전체 흐름도

[다대다 관계]                    [연관 리소스]                   [구현 세부사항]
    |                               |                              |
    v                               v                              v
+------------------+     +---------------------+     +---------------------------+
| User는 여러      |     |                     |     | 14.3.1 이름 붙이기         |
| Group에 속하고,  |---->|  Membership 리소스   |---->| CourseMembership,         |
| Group은 여러     |     |  (관계를 리소스로)    |     | UserGroup 등              |
| User를 가짐      |     |                     |     +---------------------------+
+------------------+     +---------------------+              |
                           |                         +---------------------------+
    DB의 join table        |                         | 14.3.2 표준 메서드         |
    ┌─────────┐           v                         | Create: 관계 생성 (필수)   |
    │ user_id │     +-----------+                    | Delete: 관계 제거 (필수)   |
    │ group_id│     |Membership |                    | List: 연관성 나열 (필수)   |
    │ role    │     | {userId,  |                    | Get/Update: 메타 시 필요   |
    │ joined  │     |  groupId, |                    +---------------------------+
    └─────────┘     |  role,    |                             |
                    |  expire}  |                    +---------------------------+
                    +-----------+                    | 14.3.3 고유성             |
                                                     | 같은 쌍은 1개만 존재      |
                                                     +---------------------------+
                                                              |
                                                     +---------------------------+
                                                     | 14.3.4 읽기 전용 필드     |
                                                     | userId/groupId 변경 불가  |
                                                     +---------------------------+
                                                              |
                                                     +---------------------------+
                                                     | 14.3.5 별칭 메서드         |
                                                     | ListUserGroups(userId)    |
                                                     | ListGroupUsers(groupId)   |
                                                     +---------------------------+
                                                              |
                                                     +---------------------------+
                                                     | 14.3.6 참조 무결성        |
                                                     | 캐스케이드/제한/방치       |
                                                     +---------------------------+

선수 지식 체크리스트

  • [ ] REST API 기본 개념 (리소스, 엔드포인트, HTTP 메서드)
  • [ ] 표준 메서드 (Get, Create, Update, Delete, List)
  • [ ] 교차 참조 (13장) — 일대다 관계, 참조 필드의 기본
  • [ ] DB의 join table 개념 (선택)

핵심 키워드

키워드 의미
연관 리소스 두 리소스 간의 다대다 관계를 표현하는 독립 리소스 (DB의 join table에 해당)
다대다 관계 한 리소스가 여러 상대와, 상대도 여러 리소스와 연결되는 관계
관계 메타데이터 관계 자체에 대한 추가 정보 (역할, 가입일, 만료일 등)
별칭 메서드 자주 쓰이는 필터링 쿼리를 단순화한 메서드 (ListUserGroups 등)
고유성 제약 같은 두 리소스 간 연관 리소스는 하나만 존재해야 하는 제약
읽기 전용 필드 생성 후 변경할 수 없는 필드 (userId, groupId)

14.1 서론 — 왜 연관 리소스가 필요한가?

한 줄 요약

단방향(일대다) 관계는 간단하지만, 다대다 관계와 관계 메타데이터가 필요하면 연관 리소스가 필요하다!

쉬운 설명 (학교 동아리 비유)

학교에서 학생과 동아리의 관계를 생각해보자.

  • 학생 한 명이 여러 동아리에 가입할 수 있다
  • 동아리 하나에 여러 학생이 속할 수 있다
  • 관계에 추가 정보가 있다: "언제 가입했는지", "회장인지 부원인지"

이것은 다대다 관계이며, 관계 자체에 메타데이터가 필요하다.

학생 A ──── 축구부 (역할: 회장, 가입: 2024-03)
학생 A ──── 밴드부 (역할: 부원, 가입: 2024-05)
학생 B ──── 축구부 (역할: 부원, 가입: 2024-04)
학생 B ──── 미술부 (역할: 회장, 가입: 2024-01)

DB에서는 이걸 join table로 처리한다. API에서는? → 연관 리소스(Membership)!

13장(교차 참조)으로 다대다를 처리할 수 없는 이유

# 13장 방식: User 리소스에 groupIds 배열을 저장
interface User {
    id: string;
    name: string;
    groupIds: string[];      // ← 속한 그룹 목록
}

# 문제 1: 관계 메타데이터를 어디에 저장?
# "Jimmy가 축구부에서 회장인지 부원인지" 정보를 User에? Group에? → 둘 다 어색함!

# 문제 2: 양방향 탐색이 어려움
# "축구부에 속한 모든 학생" → 모든 User를 스캔해야 함 (비효율!)

# 문제 3: 배열이 커지면 성능 문제
# 한 사용자가 1000개 그룹에 속하면 User 리소스가 비대해짐

실무 예제 — 온라인 강의 플랫폼

인프런 같은 강의 플랫폼을 생각해보자:

- 학생은 여러 강의를 수강할 수 있다
- 강의에는 여러 학생이 있을 수 있다
- 관계에 메타데이터가 있다:
  - 수강 시작일 (enrolledAt)
  - 진도율 (progress: 0~100%)
  - 수료 여부 (completed: true/false)
  - 수강권 만료일 (expireTime)

→ CourseEnrollment 연관 리소스로 해결!
// 연관 리소스로 모든 정보가 깔끔하게 표현됨
interface CourseEnrollment {
    id: string;
    studentId: string;        // 학생 참조 (읽기 전용)
    courseId: string;          // 강의 참조 (읽기 전용)
    enrolledAt: DateTime;     // 수강 시작일
    progress: number;         // 진도율 (0~100)
    completed: boolean;       // 수료 여부
    expireTime: DateTime;     // 수강권 만료일
}

핵심 체크포인트

  • ✅ 다대다 관계는 단순 참조(13장)로는 부족하다
  • ✅ 관계 자체에 메타데이터(역할, 시각 등)를 저장해야 할 때 연관 리소스 사용
  • ✅ DB의 join table = API의 연관 리소스
  • ✅ 교차 참조는 일대다에 적합, 연관 리소스는 다대다에 적합

14.2 개괄 — 연관 리소스의 기본 개념

한 줄 요약

연관 리소스는 DB의 join table을 API 리소스로 노출한 것이다. 생성=가입, 삭제=탈퇴.

데이터 모델

개체 관계                              데이터 모델

┌───────┐         ┌───────┐       ┌──────┐  ┌───────────┐  ┌───────┐
│ Group │◄───────►│ User  │       │ User │◄─┤ UserGroup │─►│ Group │
│ •id   │  다대다  │ •id   │       │ •id  │  │ •id       │  │ •id   │
│ •name │         │ •name │       │ •name│  │ •userId   │  │ •name │
└───────┘         └───────┘       └──────┘  │ •groupId  │  └───────┘
                                            │ •role     │
                                            │ •joinedAt │
                                            └───────────┘

실무 예제로 이해하는 연관 리소스

예제 1: Slack 워크스페이스의 채널 멤버십

Slack에서 사용자는 여러 채널에 참여하고, 채널에는 여러 사용자가 있다.

사용자: Alice, Bob, Charlie
채널: #general, #engineering, #random

관계 데이터:
┌────────────────────────────────────────────────────────┐
│ ChannelMembership                                      │
├──────┬──────────┬──────────────┬────────┬──────────────┤
│ id   │ userId   │ channelId    │ role   │ joinedAt     │
├──────┼──────────┼──────────────┼────────┼──────────────┤
│ cm-1 │ Alice    │ #general     │ admin  │ 2024-01-01   │
│ cm-2 │ Alice    │ #engineering │ member │ 2024-01-15   │
│ cm-3 │ Bob      │ #general     │ member │ 2024-02-01   │
│ cm-4 │ Bob      │ #engineering │ admin  │ 2024-01-10   │
│ cm-5 │ Charlie  │ #random      │ member │ 2024-03-01   │
└──────┴──────────┴──────────────┴────────┴──────────────┘

핵심: Alice는 #general에서 admin이지만 #engineering에서 member
→ 같은 사용자라도 채널마다 다른 역할! → 관계에 메타데이터가 필요!

예제 2: GitHub의 레포지토리 협력자

// GitHub API에서 실제 사용하는 패턴과 유사
interface RepositoryCollaboration {
    id: string;
    repositoryId: string;     // 레포지토리 참조
    userId: string;           // 사용자 참조
    permission: "read" | "write" | "admin";  // 권한 수준
    invitedAt: DateTime;      // 초대 시점
    acceptedAt: DateTime;     // 수락 시점 (null이면 미수락)
}

// 사용 예시
// "Alice에게 my-repo에 write 권한 부여"
POST /collaborations
{
    repositoryId: "repos/my-repo",
    userId: "users/alice",
    permission: "write"
}
// → RepositoryCollaboration { id: "collab-1", ..., invitedAt: "2024-03-15" }

연관 리소스의 3가지 강점

  1. 관계 CRUD: 생성(가입), 조회(멤버십 확인), 삭제(탈퇴)
  2. 메타데이터 저장: 역할(admin/user), 가입일, 만료일 등
  3. 양방향 탐색: "이 사용자가 속한 그룹은?" + "이 그룹의 사용자는?"

14.2.1 연관성 별칭 메서드

자주 쓰는 쿼리를 단순화한 편의 메서드이다.

# 일반적 방식: 필터로 조회
ListMemberships({ filter: "userId='users/jimmy'" })    → Jimmy가 속한 그룹
ListMemberships({ filter: "groupId='groups/2'" })      → 그룹 2의 사용자

# 별칭 메서드: 훨씬 직관적
ListUserGroups({ userId: "users/jimmy" })               → Jimmy가 속한 그룹
ListGroupUsers({ groupId: "groups/2" })                 → 그룹 2의 사용자

비유: 카페에서 주문하기

일반 방식: "메뉴에서 음료 카테고리의 따뜻한 것 중에서 에스프레소 더블샷 주세요"
별칭 방식: "아아 주세요" ← 자주 쓰는 주문을 줄임말로!

ListMemberships({ filter: "..." })  ← 일반 방식 (유연하지만 길다)
ListGroupUsers({ groupId: "2" })    ← 별칭 (자주 쓰는 패턴을 단순화)

핵심 체크포인트

  • ✅ 연관 리소스 = DB의 join table을 API 리소스로 노출
  • ✅ 생성 = 관계 수립, 삭제 = 관계 해제
  • ✅ 관계 메타데이터(역할, 가입일 등) 저장 가능
  • ✅ 별칭 메서드로 자주 쓰는 조회 패턴을 단순화

14.2.5 실전 시나리오 — 온라인 강의 플랫폼을 처음부터 구축

이 섹션은 연관 리소스의 전체 흐름을 처음부터 끝까지 따라간다. 왜 13장/15장이 안 되는지, 왜 연관 리소스여야 하는지를 단계별로 체험한다.

STEP 1: 요구사항 분석

인프런 같은 온라인 강의 플랫폼을 만든다.

핵심 요구사항:
- 학생은 여러 강의를 수강할 수 있다
- 강의에는 여러 학생이 있을 수 있다
- 관계에 추가 정보가 필요하다:
  - 수강 시작일 (enrolledAt)
  - 진도율 (progress: 0~100)
  - 수료 여부 (completed: true/false)
  - 수강권 만료일 (expireTime)

→ 다대다 관계 + 메타데이터!

STEP 2: 13장 교차 참조로 시도 → 실패

// 시도: Student 리소스에 courseIds 배열을 넣는다
interface Student {
    id: string;
    name: string;
    courseIds: string[];      // ["courses/python-101", "courses/react-basics"]
}

// 문제 1: 진도율은 어디에 저장?
// Student.progress? → 강의마다 진도가 다른데 하나만 저장?
// Student.courseProgress? → { "courses/python-101": 75, "courses/react-basics": 30 }
//   → 맵을 쓰면 되긴 하지만, 수료 여부, 만료일도 각각 맵으로?
//   → 필드가 폭발적으로 늘어남!

// 문제 2: "이 강의의 수강생 목록"을 어떻게 조회?
// → 모든 Student를 스캔하면서 courseIds에 해당 강의가 있는지 확인
// → O(N) 풀스캔! 학생 100만 명이면?

// 문제 3: 양방향 관리 불일치
// Student.courseIds를 수정하면 Course 쪽은 어떻게 알지?
// Course.studentIds도 관리해야 한다면? → 동기화 지옥!

결론: 교차 참조(13장)는 일대다 + 메타데이터 없음에 적합. 여기서는 부적합!

STEP 3: 15장 add/remove로 시도 → 부분 실패

// 시도: 관계를 add/remove로 관리
POST /courses/python-101/students:add { studentId: "students/alice" }
→ 200 OK ✅

// 문제: 진도율을 어디에 저장?
// add/remove는 관계의 존재 여부만 관리하므로 메타데이터 없음!
// "Alice의 python-101 진도율은 75%이다" → 표현할 방법이 없음!

// ListCourseStudents도 Student[]만 반환
GET /courses/python-101/students
→ [{ id: "students/alice", name: "Alice" }, ...]
// 진도율? 수료 여부? → 없음!

결론: add/remove(15장)는 다대다 + 메타데이터 없음에 적합. 여기서는 부적합!

STEP 4: 연관 리소스 설계 → 성공!

// === 리소스 인터페이스 ===

interface Student {
    id: string;            // "students/alice"
    name: string;
    email: string;
}

interface Course {
    id: string;            // "courses/python-101"
    title: string;
    instructor: string;
    description: string;
}

// 연관 리소스 — 학생과 강의 사이의 "수강 등록" 관계
interface CourseEnrollment {
    id: string;            // "enrollments/enr-001"
    studentId: string;     // ← 읽기 전용 (어떤 학생?)
    courseId: string;       // ← 읽기 전용 (어떤 강의?)
    enrolledAt: DateTime;  // 수강 시작일 (서버 자동 설정)
    progress: number;      // 진도율 (0~100)
    completed: boolean;    // 수료 여부
    expireTime: DateTime;  // 수강권 만료일
}
-- === DB 스키마 ===

CREATE TABLE students (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL
);

CREATE TABLE courses (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    instructor TEXT NOT NULL,
    description TEXT
);

CREATE TABLE enrollments (
    id TEXT PRIMARY KEY,
    student_id TEXT NOT NULL REFERENCES students(id),
    course_id TEXT NOT NULL REFERENCES courses(id),
    enrolled_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    progress INTEGER DEFAULT 0,
    completed BOOLEAN DEFAULT FALSE,
    expire_time DATETIME,
    UNIQUE(student_id, course_id)   -- ← 같은 학생+강의 쌍은 1개만!
);

STEP 5: 전체 API 호출 시나리오

// ===== 1단계: 학생과 강의 생성 =====

POST /students
{ name: "Alice Kim", email: "alice@example.com" }
→ 201 Created { id: "students/alice", ... }

POST /courses
{ title: "Python 기초", instructor: "김파이썬", description: "초보자를 위한 파이썬" }
→ 201 Created { id: "courses/python-101", ... }

POST /courses
{ title: "React 입문", instructor: "이리액트", description: "프론트엔드 시작하기" }
→ 201 Created { id: "courses/react-basics", ... }


// ===== 2단계: 수강 등록 (연관 리소스 생성) =====

POST /enrollments
{
    studentId: "students/alice",
    courseId: "courses/python-101",
    expireTime: "2026-12-31T23:59:59Z"
}
→ 201 Created
{
    id: "enrollments/enr-001",
    studentId: "students/alice",       // 읽기 전용!
    courseId: "courses/python-101",     // 읽기 전용!
    enrolledAt: "2026-03-17T09:00:00Z",// 서버가 자동 설정
    progress: 0,                       // 초기값
    completed: false,                  // 초기값
    expireTime: "2026-12-31T23:59:59Z"
}

// Alice가 React도 수강 등록
POST /enrollments
{
    studentId: "students/alice",
    courseId: "courses/react-basics",
    expireTime: "2026-12-31T23:59:59Z"
}
→ 201 Created { id: "enrollments/enr-002", ... }

// 중복 등록 시도 → 거부!
POST /enrollments
{ studentId: "students/alice", courseId: "courses/python-101" }
→ 409 Conflict "이미 수강 중입니다"


// ===== 3단계: 학습 진행 → 진도 업데이트 =====

// Alice가 Python 강의 5/10 강의를 수강 완료
PATCH /enrollments/enr-001
{ progress: 50 }
→ 200 OK
{
    id: "enrollments/enr-001",
    studentId: "students/alice",       // 변경 안 됨 (읽기 전용)
    courseId: "courses/python-101",     // 변경 안 됨 (읽기 전용)
    enrolledAt: "2026-03-17T09:00:00Z",
    progress: 50,                      // ← 업데이트됨!
    completed: false,
    expireTime: "2026-12-31T23:59:59Z"
}


// ===== 4단계: 수료 처리 =====

// Alice가 Python 전 강의 수강 완료!
PATCH /enrollments/enr-001
{ progress: 100, completed: true }
→ 200 OK { ..., progress: 100, completed: true }


// ===== 5단계: 수강 정보 조회 =====

// 5-a: 특정 수강 정보 확인
GET /enrollments/enr-001
→ 200 OK { id: "enr-001", studentId: "alice", courseId: "python-101", progress: 100, ... }

// 5-b: Alice의 모든 수강 목록 (별칭 메서드)
GET /students/alice/courses
→ 200 OK
{
    courses: [
        { id: "courses/python-101", title: "Python 기초", ... },
        { id: "courses/react-basics", title: "React 입문", ... }
    ]
}

// 5-c: Python 강의의 모든 수강생 (별칭 메서드)
GET /courses/python-101/students
→ 200 OK
{
    students: [
        { id: "students/alice", name: "Alice Kim", ... },
        { id: "students/bob", name: "Bob Lee", ... }
    ]
}

// 5-d: 메타데이터가 필요하면 ListEnrollments 사용
GET /enrollments?filter=courseId='courses/python-101'
→ 200 OK
{
    enrollments: [
        { id: "enr-001", studentId: "alice", progress: 100, completed: true, ... },
        { id: "enr-003", studentId: "bob", progress: 30, completed: false, ... }
    ]
}


// ===== 6단계: 수강 취소 =====

// Alice가 React 수강을 취소
DELETE /enrollments/enr-002
→ 204 No Content

// 확인: Alice의 수강 목록에서 React가 사라짐
GET /students/alice/courses
→ { courses: [{ id: "courses/python-101", ... }] }
// React는 더 이상 없음!

이 시나리오에서 배운 것

┌──────────────────────────────────────────────────────────────────┐
│ 13장 교차 참조: Student에 courseIds[] → 메타데이터 저장 불가 ❌   │
│ 15장 add/remove: AddCourseStudent → 메타데이터 저장 불가 ❌      │
│ 14장 연관 리소스: CourseEnrollment → 메타데이터 저장 ✅           │
│                                                                  │
│ 연관 리소스가 필요한 순간:                                        │
│ "관계 자체에 대한 질문"이 있을 때                                 │
│                                                                  │
│ - "Alice의 Python 진도율은?" → Enrollment.progress               │
│ - "Alice는 Python을 수료했나?" → Enrollment.completed            │
│ - "Alice의 수강권은 언제 만료?" → Enrollment.expireTime          │
│                                                                  │
│ 이런 질문에 답하려면 관계 자체가 리소스여야 한다!                  │
└──────────────────────────────────────────────────────────────────┘

14.3 구현

14.3.1 연관 리소스 이름 붙이기

한 줄 요약

도메인에서 자연스러운 이름을 먼저 찾고, 없으면 리소스명+Membership/Association을 사용한다.

네이밍 규칙

방식 예시 적합한 상황
도메인 용어 CourseEnrollment, Subscription 도메인에 이미 적절한 용어가 있을 때 (가장 좋음)
리소스명 + Membership ChannelMembership, TeamMembership "멤버"라는 개념이 자연스러울 때
리소스명 + Association UserProjectAssociation 중립적인 관계를 표현할 때
단독 Membership Membership API에 관계 타입이 하나뿐이고 맥락이 명확할 때

실무 예시 — 이름 짓기 실전

온라인 강의 플랫폼:
  학생 ↔ 강의 → CourseEnrollment ✅ ("수강 등록"이라는 도메인 용어)
  강사 ↔ 강의 → CourseInstructorship ❌ (어색함)
                → CourseTeaching ✅ (또는 InstructorAssignment)

Slack 클론:
  사용자 ↔ 채널 → ChannelMembership ✅ ("채널 멤버십"이 자연스러움)
  사용자 ↔ 워크스페이스 → WorkspaceMembership ✅

GitHub 클론:
  사용자 ↔ 레포 → RepositoryCollaboration ✅ ("협업자" 개념이 자연스러움)
  사용자 ↔ 조직 → OrganizationMembership ✅

쇼핑몰:
  상품 ↔ 카테고리 → ProductCategorization ✅ (또는 ProductCategoryMapping)
  사용자 ↔ 쿠폰 → CouponAssignment ✅

14.3.2 표준 메서드 동작

한 줄 요약

연관 리소스도 일반 리소스처럼 CRUD를 지원하되, Create/List/Delete는 필수이고 Get/Update는 메타데이터가 있을 때만.

표준 메서드 상세

표준 메서드 동작 필요성 HTTP
Create 두 리소스를 연결 필수 POST /memberships
Get 관계 메타데이터 확인 메타데이터 시 GET /memberships/{id}
List 리소스 간의 연관성 나열 필수 GET /memberships
Update 관계 메타데이터 수정 메타데이터 시 PATCH /memberships/{id}
Delete 리소스 간의 연관성 제거 필수 DELETE /memberships/{id}

실무 예제 — 채널 멤버십 전체 CRUD

// ===== 1. CREATE: 사용자를 채널에 추가 =====
POST /memberships
{
    channelId: "channels/engineering",
    userId: "users/alice",
    role: "member"
}
→ 201 Created
{
    id: "memberships/cm-101",
    channelId: "channels/engineering",    // 읽기 전용!
    userId: "users/alice",                // 읽기 전용!
    role: "member",
    joinedAt: "2024-03-15T09:00:00Z"     // 서버가 자동 설정
}

// ===== 2. GET: 특정 멤버십 정보 확인 =====
GET /memberships/cm-101
→ 200 OK
{
    id: "memberships/cm-101",
    channelId: "channels/engineering",
    userId: "users/alice",
    role: "member",
    joinedAt: "2024-03-15T09:00:00Z"
}

// ===== 3. LIST: 전체 멤버십 나열 (필터 가능) =====
GET /memberships?filter=channelId='channels/engineering'
→ 200 OK
{
    memberships: [
        { id: "cm-101", userId: "users/alice", role: "member", ... },
        { id: "cm-102", userId: "users/bob", role: "admin", ... }
    ],
    nextPageToken: "..."     // 페이징 지원
}

// ===== 4. UPDATE: 역할 변경 (메타데이터만!) =====
PATCH /memberships/cm-101
{
    role: "admin"            // ← 메타데이터 변경 OK
    // userId를 여기에 넣어도 무시됨! (읽기 전용)
}
→ 200 OK
{
    id: "memberships/cm-101",
    channelId: "channels/engineering",
    userId: "users/alice",    // 변경 안 됨
    role: "admin",            // ← 변경됨!
    joinedAt: "2024-03-15T09:00:00Z"
}

// ===== 5. DELETE: 채널에서 사용자 제거 =====
DELETE /memberships/cm-101
→ 204 No Content

메타데이터 없는 경우 — 간소화

// 태그 시스템: 게시글 ↔ 태그 (메타데이터 없음)
// → Get, Update가 불필요

interface PostTagging {
    id: string;
    postId: string;    // 읽기 전용
    tagId: string;     // 읽기 전용
    // 메타데이터 없음!
}

// Create, List, Delete만 지원
POST /taggings        { postId: "posts/1", tagId: "tags/javascript" }
GET  /taggings        → 전체 태그 매핑 나열
DELETE /taggings/t-1  → 태그 제거

14.3.3 고유성

한 줄 요약

같은 두 리소스를 연결하는 연관 리소스는 딱 하나만 존재할 수 있다.

왜 고유성이 필요한가?

# Alice를 #engineering에 2번 추가하면?

POST /memberships { userId: "users/alice", channelId: "channels/engineering" }
→ 201 Created ✅ (처음이니까 성공)

POST /memberships { userId: "users/alice", channelId: "channels/engineering" }
→ 409 Conflict ❌ (이미 존재!)

# 왜 막아야 하나?
# 1. Alice가 채널에 2번 추가되면 혼란 (멤버 목록에 2번 나옴)
# 2. 어떤 Membership의 role을 수정해야 하는지 모호해짐
# 3. 삭제할 때 하나만 삭제하면 여전히 관계가 남아있음

일반 리소스 vs 연관 리소스의 고유성 차이

# 일반 리소스: ID가 같으면 충돌
POST /users { id: "users/alice" }  → 201 Created
POST /users { id: "users/alice" }  → 409 Conflict (같은 ID!)

# 연관 리소스: 참조 쌍이 같으면 충돌 (ID가 달라도!)
POST /memberships { userId: "alice", channelId: "eng" }
→ memberships/cm-1 Created

POST /memberships { userId: "alice", channelId: "eng" }
→ 409 Conflict (alice+eng 쌍이 이미 존재!)
# memberships/cm-2가 되더라도 alice+eng 쌍의 중복이므로 거부!

14.3.4 읽기 전용 필드

한 줄 요약

관계의 양쪽 참조(userId, groupId)는 생성 시 설정되고, 이후 변경할 수 없다.

왜 읽기 전용인가?

# 현재 상태: Alice가 #engineering의 admin
memberships/cm-101: { userId: "alice", channelId: "engineering", role: "admin" }

# 만약 userId를 변경할 수 있다면?
PATCH /memberships/cm-101 { userId: "bob" }
→ "Alice의 engineering admin 멤버십"이 "Bob의 engineering admin 멤버십"으로 바뀜
→ Alice는 갑자기 engineering에서 퇴장!
→ Bob은 본인 의지와 무관하게 admin이 됨!

# 이건 "관계 수정"이 아니라 "다른 관계"임!
# 올바른 방법: 기존 멤버십 삭제 + 새 멤버십 생성
DELETE /memberships/cm-101                          # Alice 제거
POST /memberships { userId: "bob", channelId: "engineering", role: "admin" }  # Bob 추가

비유: 결혼 증명서

결혼 증명서에는 두 사람의 이름이 있다.
- "신랑: 김철수, 신부: 이영희, 결혼일: 2024-03-15"

이 증명서에서 "신랑: 박민수"로 바꿀 수 있는가? → 불가능!
그건 다른 결혼이다. 이혼(삭제)하고 새로 결혼(생성)해야 한다.

연관 리소스도 마찬가지:
- userId, groupId는 "누구와 누구의 관계"를 정의하는 핵심 필드
- 이것을 바꾸면 다른 관계가 되므로 변경 불가 (읽기 전용)
- 메타데이터(role, expireTime)만 업데이트 가능

Update 시 읽기 전용 필드 처리 방식

// 방식 1: 조용히 무시 (권장)
PATCH /memberships/cm-101
{ userId: "bob", role: "admin" }
→ 200 OK { userId: "alice", role: "admin" }
// userId 변경은 무시, role만 반영

// 방식 2: 에러 반환 (엄격)
PATCH /memberships/cm-101
{ userId: "bob", role: "admin" }
→ 400 Bad Request "userId is read-only and cannot be modified"

// 어느 방식이든 userId는 절대 변경되지 않는다

14.3.5 연관 별칭 메서드

한 줄 요약

자주 쓰는 "특정 리소스의 연관 목록" 쿼리를 전용 메서드로 제공한다.

상세 비교표

표 14.2 자주 쓰이는 연관 리소스 쿼리의 별칭

┌──────────────────┬───────────────────────────┬──────────────────────────┐
│ 질문              │ 리스트 필터링              │ 별칭 메서드               │
├──────────────────┼───────────────────────────┼──────────────────────────┤
│ Jimmy가 속한     │ ListMemberships({         │ ListUserGroups({         │
│ 그룹은?          │   filter: "userId: Jimmy" │   userId: "Jimmy"        │
│                  │ })                        │ })                       │
├──────────────────┼───────────────────────────┼──────────────────────────┤
│ 그룹 2에 속한    │ ListMemberships({         │ ListGroupUsers({         │
│ 사용자는?        │   filter: "groupId: 2"    │   groupId: "2"           │
│                  │ })                        │ })                       │
└──────────────────┴───────────────────────────┴──────────────────────────┘

네이밍 규칙 — 주어진 리소스(단수) + 나열할 리소스(복수)

ListUser Groups    → 주어진 "사용자"에 대한 "그룹들" 나열
     ↑      ↑
   단수   복수

ListGroup Users    → 주어진 "그룹"에 대한 "사용자들" 나열
     ↑      ↑
   단수   복수

HTTP URL 패턴

# ListUserGroups: "이 사용자가 속한 그룹들"
GET /users/{userId}/groups
GET /users/jimmy/groups
→ [{ id: "groups/1", name: "축구부" }, { id: "groups/3", name: "밴드부" }]

# ListGroupUsers: "이 그룹에 속한 사용자들"
GET /groups/{groupId}/users
GET /groups/1/users
→ [{ id: "users/jimmy", name: "Jimmy" }, { id: "users/sally", name: "Sally" }]

별칭 메서드가 반환하는 것

# 핵심: 별칭 메서드는 Membership이 아니라 "연관된 리소스"를 반환!

ListGroupUsers({ groupId: "groups/1" })
→ 반환: User[] (사용자 목록!) — Membership[]이 아님!

ListUserGroups({ userId: "users/jimmy" })
→ 반환: Group[] (그룹 목록!) — Membership[]이 아님!

# Membership 자체가 필요하면 ListMemberships 사용
ListMemberships({ filter: "groupId='groups/1'" })
→ 반환: Membership[] (멤버십 목록 + 메타데이터 포함)

14.3.6 참조 무결성

한 줄 요약

연관 리소스가 가리키는 대상이 삭제되면 어떻게 처리할 것인가?

4가지 대응 방식 상세

상황: Group "축구부"를 삭제하려 하는데,
      Jimmy와 Sally의 Membership이 이 Group을 참조 중

┌──────────────────────────────────────────────────────────────┐
│ 방식 1: 캐스케이드 (Cascade)                                  │
│ → Group 삭제 시 관련 Membership도 전부 삭제                    │
│                                                              │
│ DELETE /groups/축구부                                         │
│ → Group 삭제됨                                                │
│ → Jimmy의 축구부 Membership 자동 삭제                         │
│ → Sally의 축구부 Membership 자동 삭제                         │
│                                                              │
│ 장점: 허상 포인터 없음                                         │
│ 단점: 100만 명의 Membership이 있으면? → 삭제에 수 분 소요      │
│       연쇄 삭제 위험 (Membership 삭제가 또 다른 삭제 유발)      │
│ 결론: ⚠️ 대규모에서 비현실적                                   │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│ 방식 2: 제한 (Restrict) ← 권장!                               │
│ → Membership이 참조하는 한 Group 삭제 불가                     │
│                                                              │
│ DELETE /groups/축구부                                         │
│ → 409 Conflict "2개의 Membership이 이 Group을 참조 중입니다.  │
│   먼저 해당 Membership을 삭제해주세요."                        │
│                                                              │
│ # 올바른 절차:                                                │
│ DELETE /memberships/cm-1  (Jimmy 멤버십 삭제)                  │
│ DELETE /memberships/cm-2  (Sally 멤버십 삭제)                  │
│ DELETE /groups/축구부     (이제 삭제 가능!)                     │
│                                                              │
│ 장점: 가장 안전, 의도치 않은 데이터 유실 방지                    │
│ 단점: 삭제 전 정리 작업 필요                                    │
│ 결론: ✅ 연관 리소스에 가장 권장                                │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│ 방식 3: null 설정 (Set Null)                                  │
│ → Group 삭제 시 관련 Membership의 groupId를 null로             │
│                                                              │
│ DELETE /groups/축구부                                         │
│ → Group 삭제됨                                                │
│ → Jimmy의 Membership: { groupId: null, ... }                  │
│                                                              │
│ 장점: 허상 포인터 없음                                         │
│ 단점: 100만 건 업데이트 필요, groupId가 null인 Membership이     │
│       의미가 있는가? (어떤 그룹의 멤버인지 모름)                 │
│ 결론: ⚠️ 연관 리소스에서는 부적합                               │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│ 방식 4: 방치 (Leave as-is)                                    │
│ → Group 삭제해도 Membership은 그대로 둠                        │
│                                                              │
│ DELETE /groups/축구부                                         │
│ → Group 삭제됨                                                │
│ → Jimmy의 Membership: { groupId: "groups/축구부", ... }       │
│   (여전히 삭제된 그룹을 가리킴 → 허상 포인터!)                  │
│                                                              │
│ 장점: 단순, 삭제 메서드의 원자성/부수효과 없음 제약 충족         │
│ 단점: 클라이언트가 참조 유효성을 항상 확인해야 함                │
│ 결론: ✅ 단순한 시스템에서 사용 가능                            │
└──────────────────────────────────────────────────────────────┘

실무 권장

1순위: 제한 (Restrict) — 가장 안전
  "Membership이 있으면 Group 삭제를 막는다"
  → 관리자가 명시적으로 멤버를 먼저 정리해야 삭제 가능

2순위: 방치 (Leave as-is) — 가장 단순
  "삭제하고 허상 포인터는 클라이언트가 처리"
  → 13장에서 교차 참조의 권장 방식과 동일

비추천: 캐스케이드, null 설정 — 대규모에서 비현실적

14.3.6.1 백엔드 구현 — Flask + SQLite로 직접 만들어보기

연관 리소스의 핵심 동작을 실제 서버 코드로 구현한다. 이 코드를 복사하면 로컬에서 바로 실행할 수 있다.

DB 스키마

# schema.sql — 테이블 생성
import sqlite3

def init_db():
    """데이터베이스 초기화 — users, groups, memberships 테이블 생성"""
    db = sqlite3.connect("membership.db")
    db.executescript("""
        CREATE TABLE IF NOT EXISTS users (
            id TEXT PRIMARY KEY,
            display_name TEXT NOT NULL,
            email TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS groups_ (
            id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            description TEXT
        );

        CREATE TABLE IF NOT EXISTS memberships (
            id TEXT PRIMARY KEY,
            user_id TEXT NOT NULL REFERENCES users(id),
            group_id TEXT NOT NULL REFERENCES groups_(id),
            role TEXT DEFAULT 'member',
            joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            expire_time DATETIME,
            UNIQUE(user_id, group_id)   -- ← 고유성 제약: 같은 쌍은 1개만!
        );
    """)
    db.close()

CreateMembership — 연관 리소스 생성 (409 Conflict 처리)

from flask import Flask, request, jsonify
import sqlite3, uuid

app = Flask(__name__)

@app.route("/memberships", methods=["POST"])
def create_membership():
    """
    연관 리소스 생성 — 두 리소스를 연결한다.

    동작 흐름:
    1. 요청에서 userId, groupId 추출
    2. 해당 User, Group이 존재하는지 확인 → 404
    3. 같은 쌍이 이미 존재하는지 확인 → 409 Conflict
    4. INSERT → 201 Created
    """
    data = request.json
    user_id = data["userId"]
    group_id = data["groupId"]

    db = sqlite3.connect("membership.db")

    # 참조 대상 존재 확인
    user = db.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
    if not user:
        return jsonify({"error": f"User '{user_id}' not found"}), 404

    group = db.execute("SELECT id FROM groups_ WHERE id = ?", (group_id,)).fetchone()
    if not group:
        return jsonify({"error": f"Group '{group_id}' not found"}), 404

    # 고유성 제약 확인
    existing = db.execute(
        "SELECT id FROM memberships WHERE user_id = ? AND group_id = ?",
        (user_id, group_id)
    ).fetchone()
    if existing:
        return jsonify({"error": "이미 이 그룹의 멤버입니다"}), 409

    # 생성
    membership_id = f"memberships/{uuid.uuid4().hex[:8]}"
    role = data.get("role", "member")
    db.execute(
        "INSERT INTO memberships (id, user_id, group_id, role) VALUES (?, ?, ?, ?)",
        (membership_id, user_id, group_id, role)
    )
    db.commit()

    # 생성된 리소스 반환
    row = db.execute("SELECT * FROM memberships WHERE id = ?", (membership_id,)).fetchone()
    db.close()
    return jsonify({
        "id": row[0], "userId": row[1], "groupId": row[2],
        "role": row[3], "joinedAt": row[4], "expireTime": row[5]
    }), 201

ListGroupUsers — 별칭 메서드 (JOIN 쿼리)

@app.route("/groups/<group_id>/users", methods=["GET"])
def list_group_users(group_id):
    """
    별칭 메서드 — "이 그룹에 속한 사용자들" 반환.

    핵심: Membership[]이 아니라 User[]를 반환한다!
    내부적으로 memberships JOIN users 쿼리를 실행한다.
    """
    db = sqlite3.connect("membership.db")
    rows = db.execute("""
        SELECT u.id, u.display_name, u.email
        FROM users u
        JOIN memberships m ON u.id = m.user_id
        WHERE m.group_id = ?
        ORDER BY m.joined_at DESC
    """, (group_id,)).fetchall()
    db.close()

    return jsonify({
        "users": [
            {"id": r[0], "displayName": r[1], "email": r[2]}
            for r in rows
        ]
    })

# 반대 방향도 동일한 패턴
@app.route("/users/<user_id>/groups", methods=["GET"])
def list_user_groups(user_id):
    """별칭 메서드 — "이 사용자가 속한 그룹들" 반환."""
    db = sqlite3.connect("membership.db")
    rows = db.execute("""
        SELECT g.id, g.name, g.description
        FROM groups_ g
        JOIN memberships m ON g.id = m.group_id
        WHERE m.user_id = ?
        ORDER BY m.joined_at DESC
    """, (user_id,)).fetchall()
    db.close()

    return jsonify({
        "groups": [
            {"id": r[0], "name": r[1], "description": r[2]}
            for r in rows
        ]
    })

DeleteGroup — 참조 무결성 (Restrict 방식)

@app.route("/groups/<group_id>", methods=["DELETE"])
def delete_group(group_id):
    """
    그룹 삭제 — Restrict 방식의 참조 무결성.

    동작 흐름:
    1. 이 그룹을 참조하는 Membership이 있는지 확인
    2. 있으면 → 409 Conflict (삭제 거부)
    3. 없으면 → 삭제 진행 → 204 No Content
    """
    db = sqlite3.connect("membership.db")

    # 참조 무결성 확인 (Restrict)
    count = db.execute(
        "SELECT COUNT(*) FROM memberships WHERE group_id = ?", (group_id,)
    ).fetchone()[0]

    if count > 0:
        db.close()
        return jsonify({
            "error": f"{count}개의 멤버십이 이 그룹을 참조 중입니다. "
                     f"먼저 해당 멤버십을 삭제해주세요."
        }), 409

    db.execute("DELETE FROM groups_ WHERE id = ?", (group_id,))
    db.commit()
    db.close()
    return "", 204

실행해보기

# 1. DB 초기화 + 서버 시작
python3 -c "from app import init_db; init_db()"
flask run --port 5002

# 2. 테스트 데이터 생성
curl -X POST http://localhost:5002/users \
  -H "Content-Type: application/json" \
  -d '{"id": "users/alice", "displayName": "Alice", "email": "alice@test.com"}'

curl -X POST http://localhost:5002/groups \
  -H "Content-Type: application/json" \
  -d '{"id": "groups/engineering", "name": "Engineering", "description": "개발팀"}'

# 3. 멤버십 생성
curl -X POST http://localhost:5002/memberships \
  -H "Content-Type: application/json" \
  -d '{"userId": "users/alice", "groupId": "groups/engineering", "role": "admin"}'
# → 201 Created

# 4. 중복 생성 시도
curl -X POST http://localhost:5002/memberships \
  -H "Content-Type: application/json" \
  -d '{"userId": "users/alice", "groupId": "groups/engineering"}'
# → 409 Conflict "이미 이 그룹의 멤버입니다"

# 5. 별칭 메서드로 조회
curl http://localhost:5002/groups/groups%2Fengineering/users
# → { "users": [{ "id": "users/alice", ... }] }

# 6. 그룹 삭제 시도 (멤버십 있으므로 거부)
curl -X DELETE http://localhost:5002/groups/groups%2Fengineering
# → 409 Conflict "1개의 멤버십이 이 그룹을 참조 중입니다"

14.3.7 최종 API 정의

abstract class GroupApi {
    static version = "v1";
    static title = "Group API";

    // ===== User 표준 메서드 =====
    @post("/users")
    CreateUser(req: CreateUserRequest): User;

    @get("/{id=users/*}")
    GetUser(req: GetUserRequest): User;

    @get("/users")
    ListUsers(req: ListUsersRequest): ListUsersResponse;

    @patch("/{resource.id=users/*}")
    UpdateUser(req: UpdateUserRequest): User;

    @delete("/{id=users/*}")
    DeleteUser(req: DeleteUserRequest): void;

    // ===== Group 표준 메서드 =====
    @post("/groups")
    CreateGroup(req: CreateGroupRequest): Group;

    @get("/{id=groups/*}")
    GetGroup(req: GetGroupRequest): Group;

    @get("/groups")
    ListGroups(req: ListGroupsRequest): ListGroupsResponse;

    @patch("/{resource.id=groups/*}")
    UpdateGroup(req: UpdateGroupRequest): Group;

    @delete("/{id=groups/*}")
    DeleteGroup(req: DeleteGroupRequest): void;

    // ===== Membership 표준 메서드 (연관 리소스) =====
    @post("/memberships")
    CreateMembership(req: CreateMembershipRequest): Membership;

    @get("/{id=memberships/*}")
    GetMembership(req: GetMembershipRequest): Membership;

    @patch("/{resource.id=memberships/*}")
    UpdateMembership(req: UpdateMembershipRequest): Membership;

    @delete("/{id=memberships/*}")
    DeleteMembership(req: DeleteMembershipRequest): void;

    @get("/memberships")
    ListMemberships(req: ListMembershipsRequest): ListMembershipsResponse;

    // ===== 별칭 메서드 =====
    @get("/{groupId=groups/*}/users")          // "이 그룹의 사용자들"
    ListGroupUsers(req: ListGroupUsersRequest): ListGroupUsersResponse;

    @get("/{userId=users/*}/groups")           // "이 사용자의 그룹들"
    ListUserGroups(req: ListUserGroupsRequest): ListUserGroupsResponse;
}

// 부모 리소스
interface User {
    id: string;            // "users/alice"
    displayName: string;
    email: string;
}

interface Group {
    id: string;            // "groups/engineering"
    name: string;
    description: string;
}

// 연관 리소스 — 두 리소스 간의 관계를 표현
interface Membership {
    id: string;            // "memberships/cm-101"
    groupId: string;       // ← 읽기 전용 (참조)
    userId: string;        // ← 읽기 전용 (참조)
    role: string;          // ← 관계 메타데이터 (수정 가능)
    expireTime: DateTime;  // ← 관계 메타데이터 (수정 가능)
    joinedAt: DateTime;    // ← 서버 자동 설정 (읽기 전용)
}

API 메서드 카운트

User 관련: 5개 (Create, Get, List, Update, Delete)
Group 관련: 5개 (Create, Get, List, Update, Delete)
Membership 관련: 5개 (Create, Get, List, Update, Delete)
별칭: 2개 (ListGroupUsers, ListUserGroups)
────────────────────────────────────────────
총 17개 메서드

핵심 체크포인트

  • ✅ Create/List/Delete는 필수, Get/Update는 메타데이터 존재 시 필요
  • ✅ 같은 두 리소스 쌍의 연관은 하나만 (고유성 제약)
  • ✅ userId/groupId는 읽기 전용 — 관계 대상 변경은 삭제 후 재생성
  • ✅ 별칭 메서드: ListUserGroups, ListGroupUsers (단수+복수 패턴)
  • ✅ 참조 무결성: 제한(삭제 막기) 또는 방치(허상 허용) 권장
  • ✅ Membership은 최상위 컬렉션에 배치 (어느 한쪽의 서브리소스 아님)

14.4 트레이드오프

14.4.1 복잡성

연관성당 최대 7개 메서드(표준 5개 + 별칭 2개)를 추가해야 하므로 API 서피스가 커진다.

# 단순 접근: 사용자 메서드 1개
POST /groups/1/users:add { userId: "alice" }    ← 15장 방식 (단순하지만 메타데이터 불가)

# 연관 리소스: 최대 7개 메서드
POST /memberships                                ← CreateMembership
GET /memberships/{id}                            ← GetMembership
GET /memberships                                 ← ListMemberships
PATCH /memberships/{id}                          ← UpdateMembership
DELETE /memberships/{id}                         ← DeleteMembership
GET /groups/{id}/users                           ← ListGroupUsers (별칭)
GET /users/{id}/groups                           ← ListUserGroups (별칭)

→ 메타데이터가 필요 없으면 15장의 add/remove가 더 적합할 수 있다!

14.4.2 연관성 분리

그룹의 설명과 멤버 목록은 모두 그룹에 관한 정보이지만, 취득 방식이 완전히 다르다.

# 그룹의 기본 정보
GET /groups/1
→ { id: "groups/1", name: "축구부", description: "주말 축구 모임" }

# 그룹의 멤버 목록 — 별도 API 호출 필요!
GET /groups/1/users
→ [{ id: "users/alice", name: "Alice" }, { id: "users/bob", name: "Bob" }]

# 한 번에 모든 정보를 가져올 수 없음
# → 클라이언트가 2번 호출해야 함

14.4.3 언제 연관 리소스를 선택해야 하는가?

관계 메타데이터가 필요한가?
├── YES → 연관 리소스 (14장) 사용!
│         예: 역할, 가입일, 만료일, 권한 수준 등
│
└── NO → 관계의 존재 여부만 필요
         ├── API 복잡도를 줄이고 싶다 → add/remove (15장) 사용
         └── CRUD 일관성을 유지하고 싶다 → 연관 리소스 (14장) 사용 (메타데이터 없이)

14.4.4 실전 의사결정 — 5가지 실무 시나리오로 판단 연습

아래 시나리오를 보고 13장/14장/15장 중 어떤 패턴을 쓸지 직접 판단해보자. 답을 보기 전에 먼저 스스로 생각한 뒤 확인하자.

빠른 판단 플로우차트

Q1: 일대다인가 다대다인가?
│
├── 일대다 (한 책에 한 저자, 한 주문에 한 고객)
│   → 13장 교차 참조 (authorId, customerId)
│
└── 다대다 (학생 ↔ 강의, 사용자 ↔ 그룹)
    │
    └── Q2: 관계에 메타데이터가 필요한가?
        │   (역할, 가입일, 진도율, 권한 수준 등)
        │
        ├── YES → 14장 연관 리소스
        │
        └── NO → Q3: 나중에 메타데이터가 추가될 가능성은?
                 │
                 ├── 높음 → 14장 연관 리소스 (마이그레이션 비용 방지)
                 │          15장→14장 전환은 breaking change!
                 │
                 └── 낮음 → 15장 add/remove (가장 단순)

시나리오 1: GitHub Star (User ↔ Repository)

요구사항: 사용자가 저장소에 Star를 찍거나 취소할 수 있다.
관계 데이터: 없음. "찍었다/안 찍었다"가 전부.

→ 정답: 15장 add/remove ✅
  AddRepositoryStar({ parent: "repos/my-repo", userId: "users/alice" })
  RemoveRepositoryStar({ parent: "repos/my-repo", userId: "users/alice" })

  왜? Star에 역할도 없고 기한도 없다.
  만약 14장을 쓰면? StarMembership이라는 불필요한 리소스가 생기고,
  Get/Update/Delete 메서드까지 만들어야 한다 → 과잉 설계!

시나리오 2: Slack 채널 멤버 (User ↔ Channel)

요구사항: 사용자가 채널에 가입/탈퇴할 수 있다.
관계 데이터:
  - 역할 (admin, member, guest)
  - 가입일
  - 알림 설정 (all, mentions_only, none)
  - 마지막 읽은 메시지 위치

→ 정답: 14장 연관 리소스 ✅
  ChannelMembership { userId, channelId, role, joinedAt, notifyPreference, lastReadMessageId }

  왜? 관계에 4개의 메타데이터가 필요하다.
  만약 15장을 쓰면? "Alice는 #general의 admin인가 member인가?"를
  표현할 방법이 없다 → 핵심 기능 누락!

시나리오 3: 블로그 태그 (Post ↔ Tag)

요구사항: 게시글에 태그를 붙이거나 떼어낼 수 있다.
관계 데이터: 없음. "이 태그가 붙어있다/없다"가 전부.

→ 정답: 15장 add/remove ✅
  AddPostTag({ parent: "posts/123", tagId: "tags/javascript" })

  왜? 태그에 역할이나 기한이 없다.
  주의: "태그 순서"가 필요하면? → 순서는 관계 메타데이터이므로 14장!
  PostTagging { postId, tagId, order: number }

시나리오 4: 수강 등록 (Student ↔ Course)

요구사항: 학생이 강의를 수강 등록/취소할 수 있다.
관계 데이터:
  - 진도율 (0~100%)
  - 수료 여부
  - 수강 시작일
  - 수강권 만료일

→ 정답: 14장 연관 리소스 ✅
  CourseEnrollment { studentId, courseId, progress, completed, enrolledAt, expireTime }

  왜? 진도율과 수료 여부는 핵심 비즈니스 데이터이다.
  만약 15장을 쓰면? "Alice의 Python 진도율은?"이라는 질문에 답할 수 없다!

시나리오 5: 팀 프로젝트 배정 (Employee ↔ Project)

요구사항: 직원을 프로젝트에 배정/해제할 수 있다.
관계 데이터:
  - 역할 (PM, developer, designer, QA)
  - 참여율 (50%, 100% 등)
  - 배정 시작일, 종료일

→ 정답: 14장 연관 리소스 ✅
  ProjectAssignment { employeeId, projectId, role, allocation, startDate, endDate }

  왜? "김개발자가 프로젝트 A에서 어떤 역할인지", "참여율이 얼마인지"는
  프로젝트 관리의 핵심 질문이다.
  만약 15장을 쓰면? 배정 정보가 없어서 리소스 관리가 불가능!

정리: 잘못 선택하면 생기는 문제

올바른 선택 잘못된 선택 발생하는 문제
14장 → 15장 사용 메타데이터 저장 불가 핵심 비즈니스 데이터 누락, 나중에 14장으로 마이그레이션 필요 (breaking change)
15장 → 14장 사용 불필요한 리소스/메서드 7개 API 복잡도 증가, 클라이언트 부담 가중, 과잉 설계
13장 → 14장 사용 일대다에 join table 사용 불필요한 중간 테이블, 단순한 FK 참조로 충분한 것을 복잡하게 만듦
14장 → 13장 사용 다대다를 배열 참조로 처리 양방향 탐색 비효율, 배열 비대화, 메타데이터 저장 불가

14.5 연습문제 풀이

Q1: 온라인 쇼핑몰에서 사용자와 상품의 "위시리스트" 관계를 설계하라.

A: 메타데이터가 있으므로 연관 리소스가 적합하다. typescript interface WishlistItem { id: string; userId: string; // 읽기 전용 productId: string; // 읽기 전용 addedAt: DateTime; // 추가 시점 priority: "high" | "medium" | "low"; // 우선순위 note: string; // 메모 ("생일 선물용") } 메타데이터(우선순위, 메모)가 없다면 15장의 add/remove가 더 적합하다.

Q2: 연관 리소스가 최상위 컬렉션(/memberships)에 있어야 하는 이유는?

A: Membership은 User나 Group 어느 한쪽의 하위가 아니라, 두 리소스 간의 독립적 관계이다. /groups/1/memberships로 두면 사용자 관점의 조회가 어렵고, /users/1/memberships로 두면 그룹 관점의 조회가 어렵다. 최상위에 두면 양쪽 모두에서 필터링이 가능하다.

Q3: 다음 중 연관 리소스가 필요한 경우와 불필요한 경우를 구분하라. 1. 학생 ↔ 수업 (출석률, 성적 기록) → 필요 (메타데이터 있음) 2. 게시글 ↔ 태그 (단순 매핑) → 불필요 (15장 add/remove로 충분) 3. 의사 ↔ 환자 (담당 시작일, 전문 분야) → 필요 (메타데이터 있음) 4. 사용자 ↔ 팔로워 (팔로우 여부만) → 불필요 (15장 add/remove로 충분)


부록 A: 용어 사전

용어 영문 의미
연관 리소스 Association Resource 두 리소스 간 다대다 관계를 표현하는 독립 리소스
join table Join Table DB에서 다대다 관계를 저장하는 매핑 테이블
별칭 메서드 Aliased Method 자주 쓰이는 필터링 쿼리를 단순화한 메서드
고유성 제약 Uniqueness Constraint 동일 연관이 중복 생성되지 않도록 하는 제약
참조 무결성 Referential Integrity 모든 참조가 유효한 대상을 가리키는 상태
읽기 전용 필드 Read-only Field 생성 후 변경할 수 없는 필드
관계 메타데이터 Relationship Metadata 관계 자체에 대한 추가 정보 (역할, 가입일 등)
캐스케이드 삭제 Cascade Delete 참조 대상 삭제 시 연관 리소스도 함께 삭제
제한 Restrict 연관 리소스가 참조 중이면 대상 삭제를 막음

부록 B: 핵심 비교표

관계 모델링 방식 비교 (13장 vs 14장 vs 15장)

항목 교차 참조 (13장) 연관 리소스 (14장) add/remove (15장)
관계 유형 일대일/일대다 다대다 다대다
메타데이터 없음 ✅ 가능 없음
리소스 추가 없음 연관 리소스 1개 없음
메서드 추가 없음 최대 7개 4개
복잡도 낮음 높음 중간
양방향 탐색 어려움 ✅ 별칭 메서드 ✅ 리스트 메서드
관계의 대칭성 - ✅ 대칭 ❌ 비대칭

참조 무결성 방식 비교

방식 허상 포인터 대규모 적합 삭제 복잡도 권장
캐스케이드 없음 높음 (연쇄 삭제) ⚠️
제한 없음 중간 (사전 정리) ✅ 1순위
null 설정 없음 높음 (대량 업데이트) ⚠️
방치 있음 낮음 (즉시 삭제) ✅ 2순위

부록 C: 추천 참고 자료 & 링크

자료 설명
AIP-124 (google.aip.dev) Google의 연관 리소스(Resource association) 가이드라인
13장 교차 참조 단순 참조의 기본 (연관 리소스의 선수 지식)
15장 add/remove 연관 리소스의 더 단순한 대안
7장 표준 메서드 CRUD 메서드의 기본 원리
난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플